--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 266930131c77ab8e430c7a78cfd3a7d32d672dbe
Parents : 39c5ae7
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-13T22:04:49-05:00
feat(nomad): update Nomad page rendering with new configuration options for markdown, HTML, and plaintext, and implement default page path settings
Changes
8 files changed, 569 insertions(+), 53 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index e0252c39..26219ba2 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -1631,7 +1631,8 @@ class ReticulumMeshChat:
break
self.queue_crawler_task(
node["destination_hash"],
- "/page/index.mu",
+ ctx.config.nomad_default_page_path.get()
+ or "/page/index.mu",
context=ctx,
)
@@ -9522,6 +9523,8 @@ class ReticulumMeshChat:
if response is None:
return None
path = request.path
+ if path.startswith("/api/"):
+ return response
if path.endswith(".js") or path.endswith(".mjs"):
response.headers["Content-Type"] = (
"application/javascript; charset=utf-8"
@@ -10329,6 +10332,39 @@ class ReticulumMeshChat:
self._parse_bool(data["telemetry_enabled"]),
)
+ if "nomad_render_markdown_enabled" in data:
+ self.config.nomad_render_markdown_enabled.set(
+ self._parse_bool(data["nomad_render_markdown_enabled"]),
+ )
+
+ if "nomad_render_html_enabled" in data:
+ self.config.nomad_render_html_enabled.set(
+ self._parse_bool(data["nomad_render_html_enabled"]),
+ )
+
+ if "nomad_render_plaintext_enabled" in data:
+ self.config.nomad_render_plaintext_enabled.set(
+ self._parse_bool(data["nomad_render_plaintext_enabled"]),
+ )
+
+ if "nomad_default_page_path" in data:
+ from meshchatx.src.backend.page_node import is_allowed_page_filename
+
+ raw = data["nomad_default_page_path"]
+ if raw is None or str(raw).strip() == "":
+ self.config.nomad_default_page_path.set("/page/index.mu")
+ else:
+ s = str(raw).strip()
+ if s.startswith("/page/"):
+ base = s[6:]
+ if (
+ base
+ and "/" not in base
+ and ".." not in base
+ and is_allowed_page_filename(base)
+ ):
+ self.config.nomad_default_page_path.set(s)
+
if "block_attachments_from_strangers" in data:
self.config.block_attachments_from_strangers.set(
self._parse_bool(data["block_attachments_from_strangers"]),
@@ -11584,6 +11620,10 @@ class ReticulumMeshChat:
"location_manual_lon": ctx.config.location_manual_lon.get(),
"location_manual_alt": ctx.config.location_manual_alt.get(),
"telemetry_enabled": ctx.config.telemetry_enabled.get(),
+ "nomad_render_markdown_enabled": ctx.config.nomad_render_markdown_enabled.get(),
+ "nomad_render_html_enabled": ctx.config.nomad_render_html_enabled.get(),
+ "nomad_render_plaintext_enabled": ctx.config.nomad_render_plaintext_enabled.get(),
+ "nomad_default_page_path": ctx.config.nomad_default_page_path.get(),
}
# try and get a name for the provided identity hash
@@ -13268,7 +13308,10 @@ class ReticulumMeshChat:
)
# queue crawler task (existence check in queue_crawler_task handles duplicates)
- self.queue_crawler_task(destination_hash.hex(), "/page/index.mu")
+ self.queue_crawler_task(
+ destination_hash.hex(),
+ self.config.nomad_default_page_path.get() or "/page/index.mu",
+ )
def _try_serve_local_page_node(self, destination_hash, page_path):
"""If destination_hash belongs to one of our page nodes, serve the page
@@ -13280,6 +13323,7 @@ class ReticulumMeshChat:
page_name = page_path.lstrip("/")
if page_name.startswith("page/"):
page_name = page_name[5:]
+ page_name = os.path.basename(page_name)
content = node.get_page_content(page_name)
if content is not None:
node._stats["pages_served"] += 1
@@ -13297,6 +13341,8 @@ class ReticulumMeshChat:
if file_name.startswith("file/"):
file_name = file_name[5:]
file_name = os.path.basename(file_name)
+ if not file_name or file_name in (".", ".."):
+ return None
full_path = os.path.join(node.files_dir, file_name)
if os.path.isfile(full_path):
with open(full_path, "rb") as f:
diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index 6385a3a1..bb765e0a 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -385,6 +385,27 @@ class ConfigManager:
self.csp_extra_script_src = self.StringConfig(self, "csp_extra_script_src", "")
self.csp_extra_style_src = self.StringConfig(self, "csp_extra_style_src", "")
+ self.nomad_render_markdown_enabled = self.BoolConfig(
+ self,
+ "nomad_render_markdown_enabled",
+ True,
+ )
+ self.nomad_render_html_enabled = self.BoolConfig(
+ self,
+ "nomad_render_html_enabled",
+ True,
+ )
+ self.nomad_render_plaintext_enabled = self.BoolConfig(
+ self,
+ "nomad_render_plaintext_enabled",
+ True,
+ )
+ self.nomad_default_page_path = self.StringConfig(
+ self,
+ "nomad_default_page_path",
+ "/page/index.mu",
+ )
+
self._migrate_legacy_announce_limit_keys()
def get(self, key: str, default_value=None) -> str | None:
diff --git a/meshchatx/src/backend/page_node.py b/meshchatx/src/backend/page_node.py
index 523a24fb..e23a71a3 100644
--- a/meshchatx/src/backend/page_node.py
+++ b/meshchatx/src/backend/page_node.py
@@ -15,6 +15,7 @@ Supported page filename extensions are ``.mu``, ``.md``, ``.txt``, and ``.html``
import json
import os
+import time
import RNS
@@ -31,6 +32,8 @@ def normalize_page_filename(name: str) -> str:
name = os.path.basename((name or "").strip())
if not name or name in (".", ".."):
raise ValueError("page name is required")
+ if "/" in name or "\\" in name:
+ raise ValueError("invalid page name")
lower = name.lower()
for ext in ALLOWED_PAGE_EXTENSIONS:
if lower.endswith(ext):
@@ -45,6 +48,16 @@ def is_allowed_page_filename(name: str) -> bool:
return any(lower.endswith(ext) for ext in ALLOWED_PAGE_EXTENSIONS)
+def _safe_mesh_file_basename(name: str) -> str:
+ """Reject empty, dot, or parent-segment names after basename (path traversal)."""
+ base = os.path.basename((name or "").strip())
+ if not base or base in (".", ".."):
+ raise ValueError("invalid file name")
+ if "/" in base or "\\" in base:
+ raise ValueError("invalid file name")
+ return base
+
+
class PageNode:
"""A single page-serving node on the Reticulum mesh."""
@@ -63,6 +76,8 @@ class PageNode:
self._registered_page_paths = set()
self._registered_file_paths = set()
self._stats = {"pages_served": 0, "files_served": 0, "links_established": 0}
+ self._serve_started_at = None
+ self._unique_remote_hashes = set()
def setup(self):
"""Create directories, load or create identity, set up RNS destination."""
@@ -91,6 +106,7 @@ class PageNode:
self._ensure_local_path()
self.running = True
+ self._serve_started_at = time.time()
return self.destination.hash.hex()
def announce(self):
@@ -103,6 +119,8 @@ class PageNode:
def teardown(self):
"""Deregister handlers and clean up."""
self.running = False
+ self._serve_started_at = None
+ self._unique_remote_hashes.clear()
if self.destination:
for rpath in list(self._registered_page_paths):
self.destination.deregister_request_handler(rpath)
@@ -121,9 +139,23 @@ class PageNode:
pass
self.active_links.clear()
+ def _note_remote_identity(self, remote_identity):
+ if remote_identity is None:
+ return
+ try:
+ h = getattr(remote_identity, "hash", None)
+ if h is not None:
+ self._unique_remote_hashes.add(h.hex())
+ except Exception:
+ pass
+
def _link_established(self, link):
self.active_links.append(link)
self._stats["links_established"] += 1
+ try:
+ self._note_remote_identity(link.get_remote_identity())
+ except Exception:
+ pass
link.set_link_closed_callback(self._link_closed)
def _link_closed(self, link):
@@ -223,6 +255,7 @@ class PageNode:
"""Return a closure that serves a specific page."""
def responder(path, data, request_id, link_id, remote_identity, requested_at):
+ self._note_remote_identity(remote_identity)
safe_name = os.path.basename(page_name)
page_path = os.path.join(self.pages_dir, safe_name)
if not os.path.isfile(page_path):
@@ -241,6 +274,7 @@ class PageNode:
"""Return a closure that serves a specific file."""
def responder(path, data, request_id, link_id, remote_identity, requested_at):
+ self._note_remote_identity(remote_identity)
safe_name = os.path.basename(file_name)
file_path = os.path.join(self.files_dir, safe_name)
if not os.path.isfile(file_path):
@@ -305,7 +339,7 @@ class PageNode:
def add_file(self, name, data):
"""Write a file and register its request handler."""
- name = os.path.basename(name)
+ name = _safe_mesh_file_basename(name)
file_path = os.path.join(self.files_dir, name)
mode = "wb" if isinstance(data, bytes) else "w"
with open(file_path, mode) as f:
@@ -316,7 +350,10 @@ class PageNode:
def remove_file(self, name):
"""Remove a file and deregister its request handler."""
- name = os.path.basename(name)
+ try:
+ name = _safe_mesh_file_basename(name)
+ except ValueError:
+ return False
file_path = os.path.join(self.files_dir, name)
if os.path.isfile(file_path):
os.remove(file_path)
@@ -343,6 +380,9 @@ class PageNode:
def get_status(self):
"""Return current node status dict."""
+ uptime_seconds = 0
+ if self.running and self._serve_started_at is not None:
+ uptime_seconds = max(0, int(time.time() - self._serve_started_at))
return {
"node_id": self.node_id,
"name": self.name,
@@ -350,6 +390,8 @@ class PageNode:
"destination_hash": self.get_destination_hash(),
"identity_hash": self.identity.hash.hex() if self.identity else None,
"active_links": len(self.active_links),
+ "unique_connections": len(self._unique_remote_hashes),
+ "uptime_seconds": uptime_seconds,
"pages": self.list_pages(),
"files": self.list_files(),
"stats": dict(self._stats),
diff --git a/meshchatx/src/frontend/components/archives/ArchivesPage.vue b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
index c8aff9b1..241a5613 100644
--- a/meshchatx/src/frontend/components/archives/ArchivesPage.vue
+++ b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
@@ -216,11 +216,13 @@
<MaterialDesignIcon icon-name="archive-clock-outline" class="size-16 opacity-20" />
<div class="text-sm font-bold uppercase tracking-widest opacity-50">Select a snapshot to view</div>
</div>
- <pre
+ <div
v-else
- class="h-full break-words whitespace-pre-wrap text-white selection:bg-blue-500/30"
+ class="h-full selection:bg-blue-500/30"
+ :class="archiveViewerClasses"
+ @click.capture="onArchiveContentClick"
v-html="renderedContent"
- ></pre>
+ ></div>
</div>
</div>
</div>
@@ -230,6 +232,8 @@
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import Utils from "../../js/Utils";
import MicronParser from "../../js/MicronParser.js";
+import GlobalState from "../../js/GlobalState.js";
+import { renderNomadPageByPath } from "../../js/NomadPageRenderer.js";
import ArchiveSidebar from "./ArchiveSidebar.vue";
export default {
@@ -242,7 +246,6 @@ export default {
return {
archives: [],
isLoading: false,
- muParser: new MicronParser(),
selectedNodeHash: null,
viewingArchive: null,
isSidebar1Hidden: false,
@@ -257,6 +260,16 @@ export default {
};
},
computed: {
+ nomadRenderOptions() {
+ const c = GlobalState.config || {};
+ const hash = this.viewingArchive?.destination_hash || null;
+ return {
+ renderMarkdown: c.nomad_render_markdown_enabled !== false,
+ renderHtml: c.nomad_render_html_enabled !== false,
+ renderPlaintext: c.nomad_render_plaintext_enabled !== false,
+ nomadDestinationHash: hash,
+ };
+ },
selectedNode() {
if (!this.selectedNodeHash) return null;
return this.groupedArchives.find((g) => g.destination_hash === this.selectedNodeHash);
@@ -265,6 +278,31 @@ export default {
if (!this.selectedNode || this.selectedNode.archives.length === 0) return false;
return this.selectedNode.archives.every((a) => this.selectedArchives.includes(a.id));
},
+ archiveViewerClasses() {
+ const a = this.viewingArchive;
+ if (!a?.page_path) {
+ return ["break-words", "whitespace-pre-wrap", "text-gray-100"];
+ }
+ const pl = (a.page_path || "").split("`")[0].toLowerCase();
+ const isRich = pl.endsWith(".mu") || pl.endsWith(".md") || pl.endsWith(".html");
+ const isHtml = pl.endsWith(".html");
+ const isMd = pl.endsWith(".md");
+ const classes = ["break-words"];
+ if (isRich) {
+ classes.push("nomad-page-rich");
+ } else {
+ classes.push("whitespace-pre-wrap");
+ }
+ if (isHtml) {
+ classes.push("nomad-page-html-host");
+ } else {
+ classes.push("text-gray-100");
+ }
+ if (isMd) {
+ classes.push("nomad-markdown-host");
+ }
+ return classes;
+ },
groupedArchives() {
// Optimization: Use a simple object for grouping
const groups = {};
@@ -428,6 +466,42 @@ export default {
formatDate(dateStr) {
return Utils.formatTimeAgo(dateStr);
},
+ onArchiveContentClick(event) {
+ const nomadLink = event.target.closest("a.nomadnet-link[data-nomadnet-url]");
+ if (nomadLink) {
+ event.preventDefault();
+ event.stopPropagation();
+ const url = nomadLink.getAttribute("data-nomadnet-url");
+ if (!url) {
+ return;
+ }
+ const [hash, ...pathParts] = url.split(":");
+ const path = pathParts.join(":");
+ this.$router.push({
+ name: "nomadnetwork",
+ params: { destinationHash: hash },
+ query: { path: path },
+ });
+ return;
+ }
+ const fragAnchor = event.target.closest("a[href]");
+ if (
+ fragAnchor &&
+ fragAnchor.getAttribute("href") &&
+ fragAnchor.getAttribute("href") !== "#" &&
+ fragAnchor.getAttribute("href").startsWith("#") &&
+ !fragAnchor.getAttribute("data-nomadnet-url")
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ const raw = fragAnchor.getAttribute("href").slice(1);
+ const id = decodeURIComponent(raw);
+ const el = document.getElementById(id);
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+ }
+ },
downloadTextAsFile(content, filename) {
const blob = new Blob([content ?? ""], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
@@ -447,16 +521,21 @@ export default {
},
muExportFilename(archive) {
let base = this.muExportBasename(archive);
- if (base.toLowerCase().endsWith(".mu")) {
+ const lower = base.toLowerCase();
+ const allowed = [".mu", ".md", ".txt", ".html"];
+ if (allowed.some((ext) => lower.endsWith(ext))) {
return base;
}
const without = base.includes(".") ? base.replace(/\.[^.]+$/, "") : base;
return `${without || "page"}.mu`;
},
muExportFilenameDisambiguated(archive) {
- const stem = this.muExportFilename(archive).replace(/\.mu$/i, "");
+ const name = this.muExportFilename(archive);
+ const m = name.match(/^(.+)(\.[^.]+)$/);
+ const stem = m ? m[1] : name.replace(/\.[^.]+$/, "");
+ const ext = m ? m[2] : ".mu";
const short = (archive.hash || "snap").substring(0, 8);
- return `${stem}_${short}.mu`;
+ return `${stem}_${short}${ext}`;
},
exportArchiveAsMu(archive) {
if (!archive) {
@@ -473,27 +552,26 @@ export default {
});
},
renderFullContent(archive) {
- if (!archive.content) return "";
-
- // convert micron to html if it looks like micron or ends with .mu
- if (archive.page_path?.endsWith(".mu") || archive.content.includes("`")) {
- try {
- // Optimization: check if content is extremely large and maybe simplify rendering
- // For now, just catch potential parser errors
- return this.muParser.convertMicronToHtml(archive.content);
- } catch (e) {
- console.error("Micron parsing failed", e);
- return archive.content;
+ if (!archive.content) {
+ return "";
+ }
+ const pathPart = (archive.page_path || "").split("`")[0];
+ const pl = pathPart.toLowerCase();
+ const hasKnownExt = /\.(mu|md|txt|html)$/.test(pl);
+ try {
+ if (!hasKnownExt && archive.content.includes("`")) {
+ return new MicronParser().convertMicronToHtml(archive.content, {});
}
+ return renderNomadPageByPath(pathPart, archive.content, {}, MicronParser, this.nomadRenderOptions);
+ } catch (e) {
+ console.error("Archive render failed", e);
+ return String(archive.content)
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
-
- // otherwise, we will just serve the raw content, making sure to prevent injecting html
- return archive.content
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
},
},
};
@@ -547,3 +625,85 @@ pre {
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
</style>
+
+<style>
+/* Match NomadNetworkPage so archives render Markdown/HTML before that route is loaded */
+.nomad-markdown-host {
+ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+}
+
+.nomad-markdown-host .nomad-markdown {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.nomad-markdown-host .nomad-markdown table {
+ white-space: normal;
+}
+
+.nomad-markdown-host .nomad-markdown h1 {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+ font-weight: 700;
+ margin: 0.75rem 0 0.5rem;
+}
+
+.nomad-markdown-host .nomad-markdown h2 {
+ font-size: 1.5rem;
+ line-height: 2rem;
+ font-weight: 700;
+ margin: 0.65rem 0 0.45rem;
+}
+
+.nomad-markdown-host .nomad-markdown h3 {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+ font-weight: 600;
+ margin: 0.55rem 0 0.4rem;
+}
+
+.nomad-markdown-host .nomad-markdown h4 {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+ font-weight: 600;
+ margin: 0.5rem 0 0.35rem;
+}
+
+.nomad-markdown-host .nomad-markdown h5,
+.nomad-markdown-host .nomad-markdown h6 {
+ font-size: 1rem;
+ line-height: 1.5rem;
+ font-weight: 600;
+ margin: 0.45rem 0 0.3rem;
+}
+
+.nomad-markdown-host .nomad-markdown p {
+ margin: 0.4rem 0;
+}
+
+.nomad-markdown-host .nomad-markdown ul,
+.nomad-markdown-host .nomad-markdown ol {
+ margin: 0.4rem 0;
+ padding-left: 1.5rem;
+}
+
+.nomad-markdown-host .nomad-markdown blockquote {
+ margin: 0.5rem 0;
+ padding-left: 0.75rem;
+ border-left: 3px solid rgb(107 114 128);
+}
+
+.nomad-markdown-host .nomad-markdown pre {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-x: auto;
+}
+
+.nomad-page-html-host {
+ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+}
+
+.nomad-page-html-host .nomad-html-root {
+ color: rgb(229 231 235);
+}
+</style>
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 106e07d8..51d26778 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -241,14 +241,23 @@
</IconButton>
</div>
- <!-- page content -->
+ <!-- page content: capture-phase clicks so <a href> is handled before browser default navigation -->
<div
- class="flex-1 overflow-y-auto p-3 bg-black text-white nodeContainer relative [contain:layout_paint]"
+ :class="[
+ 'flex-1 overflow-y-auto nodeContainer relative [contain:layout_paint]',
+ nomadRenderedShellFullBleed
+ ? 'p-0 bg-transparent text-gray-900 dark:text-gray-100'
+ : 'p-3 bg-black text-white',
+ ]"
+ @click.capture="onElementClick"
>
<!-- archived version notice -->
<div
v-if="isShowingArchivedVersion"
- class="mb-4 p-2 bg-yellow-900/40 border border-yellow-700/50 rounded flex items-center justify-between text-yellow-200"
+ :class="[
+ 'mb-4 p-2 bg-yellow-900/40 border border-yellow-700/50 rounded flex items-center justify-between text-yellow-200',
+ nomadRenderedShellFullBleed ? 'mx-3 mt-3' : '',
+ ]"
>
<div class="flex items-center gap-2">
<MaterialDesignIcon icon-name="clock" class="size-4" />
@@ -444,7 +453,6 @@ export default {
isLoadingNodePage: false,
isShowingNodePageSource: false,
- defaultNodePagePath: "/page/index.mu",
nodePageRequestSequence: 0,
nodePagePath: null,
nodePagePathUrlInput: null,
@@ -487,6 +495,19 @@ export default {
};
},
computed: {
+ defaultNodePagePath() {
+ const p = GlobalState.config?.nomad_default_page_path;
+ return typeof p === "string" && p.startsWith("/page/") ? p : "/page/index.mu";
+ },
+ nomadRenderOptions() {
+ const c = GlobalState.config || {};
+ return {
+ renderMarkdown: c.nomad_render_markdown_enabled !== false,
+ renderHtml: c.nomad_render_html_enabled !== false,
+ renderPlaintext: c.nomad_render_plaintext_enabled !== false,
+ nomadDestinationHash: this.selectedNode?.destination_hash || null,
+ };
+ },
blockedDestinations() {
return GlobalState.blockedDestinations;
},
@@ -527,6 +548,25 @@ export default {
}
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
},
+ nomadRenderedShellFullBleed() {
+ if (!this.nodePagePath || this.isShowingNodePageSource) {
+ return false;
+ }
+ if (this.isLoadingNodePage) {
+ return false;
+ }
+ if (
+ this.nodePageContent === "request_failed" ||
+ (this.nodePageContent && String(this.nodePageContent).includes("failure"))
+ ) {
+ return false;
+ }
+ const [p] = this.nodePagePath.split("`");
+ if ((p || "").toLowerCase().endsWith(".mu")) {
+ return false;
+ }
+ return true;
+ },
nomadPageContentClasses() {
if (!this.nodePagePath || this.isShowingNodePageSource) {
return ["h-full", "break-words", "whitespace-pre-wrap", "text-gray-100"];
@@ -537,6 +577,9 @@ export default {
const isHtml = pl.endsWith(".html");
const isMd = pl.endsWith(".md");
const classes = ["h-full", "break-words"];
+ if (this.nomadRenderedShellFullBleed && !isHtml) {
+ classes.push("px-3", "py-3");
+ }
if (isRich) {
classes.push("nomad-page-rich");
} else {
@@ -544,8 +587,10 @@ export default {
}
if (isHtml) {
classes.push("nomad-page-html-host");
- } else {
+ } else if (pl.endsWith(".mu")) {
classes.push("text-gray-100");
+ } else {
+ classes.push("text-gray-900", "dark:text-gray-100");
}
if (isMd) {
classes.push("nomad-markdown-host");
@@ -584,15 +629,11 @@ export default {
this.clearPartials();
WebSocketConnection.off("message", this.onWebsocketMessage);
- window.document.removeEventListener("click", this.onElementClick);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
- // listen for element clicks
- window.document.addEventListener("click", this.onElementClick);
-
// load nomadnetwork node if a destination hash was provided on page load
if (this.destinationHash) {
(async () => {
@@ -668,6 +709,36 @@ export default {
);
},
onElementClick(event) {
+ const nomadLink = event.target.closest("a.nomadnet-link[data-nomadnet-url]");
+ if (nomadLink) {
+ event.preventDefault();
+ event.stopPropagation();
+ const url = nomadLink.getAttribute("data-nomadnet-url");
+ if (url) {
+ this.onNodePageUrlClick(url);
+ }
+ return;
+ }
+
+ const fragAnchor = event.target.closest("a[href]");
+ if (
+ fragAnchor &&
+ fragAnchor.getAttribute("href") &&
+ fragAnchor.getAttribute("href") !== "#" &&
+ fragAnchor.getAttribute("href").startsWith("#") &&
+ !fragAnchor.getAttribute("data-nomadnet-url")
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ const raw = fragAnchor.getAttribute("href").slice(1);
+ const id = decodeURIComponent(raw);
+ const el = document.getElementById(id);
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+ return;
+ }
+
const element = event.target.closest('[data-action="openNode"]');
if (!element) {
return;
@@ -1268,7 +1339,13 @@ export default {
if (!this.isShowingNodePageSource) {
// address:/page/index.mu`Data=123
const [pagePathWithoutData] = path.split("`");
- return renderNomadPageByPath(pagePathWithoutData, content, this.pagePartials, MicronParser);
+ return renderNomadPageByPath(
+ pagePathWithoutData,
+ content,
+ this.pagePartials,
+ MicronParser,
+ this.nomadRenderOptions
+ );
}
return content
@@ -1926,11 +2003,26 @@ pre.text-wrap > div > :last-child {
overflow-x: auto;
}
+.nomad-markdown-host .nomad-markdown a.nomadnet-link,
+.nomad-markdown-host .nomad-markdown a[href^="#"]:not([href="#"]) {
+ cursor: pointer;
+ pointer-events: auto;
+}
+
.nomad-page-html-host {
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+ min-height: 100%;
+ width: 100%;
}
.nomad-page-html-host .nomad-html-root {
color: rgb(229 231 235);
+ min-height: 100%;
+}
+
+.nomad-page-html-host .nomad-html-root a.nomadnet-link,
+.nomad-page-html-host .nomad-html-root a[href^="#"]:not([href="#"]) {
+ cursor: pointer;
+ pointer-events: auto;
}
</style>
diff --git a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
index e573e951..8b8575f7 100644
--- a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
+++ b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
@@ -104,10 +104,15 @@
</div>
</div>
- <div v-if="node.stats" class="flex gap-4 text-xs text-gray-500 dark:text-gray-400">
- <span>{{ node.stats.pages_served }} pages served</span>
- <span>{{ node.stats.files_served }} files served</span>
- <span>{{ node.stats.links_established }} links</span>
+ <div
+ v-if="node.stats || node.running"
+ class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400"
+ >
+ <span v-if="node.running">{{ formatMeshUptime(node.uptime_seconds) }} uptime</span>
+ <span>{{ node.unique_connections ?? 0 }} unique connections</span>
+ <span v-if="node.stats">{{ node.stats.pages_served }} pages served</span>
+ <span v-if="node.stats">{{ node.stats.files_served }} files served</span>
+ <span v-if="node.stats">{{ node.stats.links_established }} links</span>
</div>
</div>
</div>
@@ -486,8 +491,16 @@ export default {
const response = await window.api.get(
`/api/v1/page-nodes/${this.selectedNode.node_id}/pages/${encodeURIComponent(pageName)}`
);
+ let body = response.data;
+ if (typeof body === "string") {
+ try {
+ body = JSON.parse(body);
+ } catch {
+ body = {};
+ }
+ }
this.editingPage = pageName;
- this.editingPageContent = response.data.content;
+ this.editingPageContent = body?.content ?? "";
} catch {
this.showStatus("Failed to load page", false);
}
@@ -563,6 +576,26 @@ export default {
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
},
+ formatMeshUptime(seconds) {
+ if (seconds == null || seconds < 0) return "—";
+ let s = Math.floor(seconds);
+ if (s < 60) return `${s}s`;
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
+ if (s < 30 * 86400) return `${Math.floor(s / 86400)}d`;
+ const yearSec = 365 * 86400;
+ const monthSec = 30 * 86400;
+ const years = Math.floor(s / yearSec);
+ s -= years * yearSec;
+ const months = Math.floor(s / monthSec);
+ s -= months * monthSec;
+ const days = Math.floor(s / 86400);
+ const parts = [];
+ if (years) parts.push(`${years} year${years === 1 ? "" : "s"}`);
+ if (months) parts.push(`${months} month${months === 1 ? "" : "s"}`);
+ if (days) parts.push(`${days} day${days === 1 ? "" : "s"}`);
+ return parts.length ? parts.join(" ") : "0d";
+ },
showStatus(message, success) {
this.statusMessage = message;
this.statusSuccess = success;
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index ee65c7bd..59dc1807 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -661,6 +661,85 @@
</div>
</section>
+ <!-- NomadNet browser renderer -->
+ <section
+ v-show="matchesSearch(...sectionKeywords.nomadRenderer)"
+ class="settings-section break-inside-avoid"
+ >
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">Browsing</div>
+ <h2>NomadNet browser renderer</h2>
+ <p>
+ Control how Micron, Markdown, HTML, and plain text pages are rendered in the Nomad
+ browser and archives. Set the default page path when opening a node without a path.
+ </p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-3">
+ <label class="setting-toggle">
+ <Toggle
+ id="nomad-render-markdown"
+ v-model="config.nomad_render_markdown_enabled"
+ @update:model-value="onNomadRendererMarkdownToggle"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">Render Markdown (.md) pages</span>
+ <span class="setting-toggle__description"
+ >When off, .md files are shown as escaped text instead of formatted
+ Markdown.</span
+ >
+ </span>
+ </label>
+ <label class="setting-toggle">
+ <Toggle
+ id="nomad-render-html"
+ v-model="config.nomad_render_html_enabled"
+ @update:model-value="onNomadRendererHtmlToggle"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">Render HTML (.html) pages</span>
+ <span class="setting-toggle__description"
+ >When off, .html files are shown as escaped text instead of sanitized
+ HTML.</span
+ >
+ </span>
+ </label>
+ <label class="setting-toggle">
+ <Toggle
+ id="nomad-render-plaintext"
+ v-model="config.nomad_render_plaintext_enabled"
+ @update:model-value="onNomadRendererPlaintextToggle"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">Render plain text (.txt) pages</span>
+ <span class="setting-toggle__description"
+ >When off, .txt files use a simpler escaped layout.</span
+ >
+ </span>
+ </label>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ Default page path (no URL path)
+ </div>
+ <select
+ v-model="config.nomad_default_page_path"
+ class="input-field max-w-xl"
+ @change="onNomadDefaultPagePathChange"
+ >
+ <option value="/page/index.mu">/page/index.mu (Micron)</option>
+ <option value="/page/index.html">/page/index.html (HTML)</option>
+ <option value="/page/index.md">/page/index.md (Markdown)</option>
+ <option value="/page/index.txt">/page/index.txt (plain text)</option>
+ </select>
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ Used when opening a Nomad node without a path, for hash-only links, and for the
+ Smart Crawler homepage fetch.
+ </div>
+ </div>
+ </div>
+ </section>
+
<!-- Smart Crawler -->
<section
v-show="matchesSearch(...sectionKeywords.crawler)"
@@ -2008,6 +2087,10 @@ export default {
csp_extra_frame_src: "",
csp_extra_script_src: "",
csp_extra_style_src: "",
+ nomad_render_markdown_enabled: true,
+ nomad_render_html_enabled: true,
+ nomad_render_plaintext_enabled: true,
+ nomad_default_page_path: "/page/index.mu",
},
saveTimeouts: {},
lastRememberedInboundStampCost: 8,
@@ -2072,6 +2155,18 @@ export default {
"app.desktop_hardware_acceleration_enabled_description",
],
archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
+ nomadRenderer: [
+ "NomadNet",
+ "NomadNet browser renderer",
+ "browser",
+ "renderer",
+ "markdown",
+ "HTML",
+ "plaintext",
+ "index.mu",
+ "index.html",
+ "default page",
+ ],
crawler: ["Discovery", "Smart Crawler", "crawler", "crawl", "retries", "delay", "concurrent"],
csp: [
"Security",
@@ -2636,6 +2731,41 @@ export default {
);
}, 1000);
},
+ async onNomadRendererMarkdownToggle(value) {
+ this.config.nomad_render_markdown_enabled = value;
+ await this.updateConfig(
+ {
+ nomad_render_markdown_enabled: this.config.nomad_render_markdown_enabled,
+ },
+ null
+ );
+ },
+ async onNomadRendererHtmlToggle(value) {
+ this.config.nomad_render_html_enabled = value;
+ await this.updateConfig(
+ {
+ nomad_render_html_enabled: this.config.nomad_render_html_enabled,
+ },
+ null
+ );
+ },
+ async onNomadRendererPlaintextToggle(value) {
+ this.config.nomad_render_plaintext_enabled = value;
+ await this.updateConfig(
+ {
+ nomad_render_plaintext_enabled: this.config.nomad_render_plaintext_enabled,
+ },
+ null
+ );
+ },
+ async onNomadDefaultPagePathChange() {
+ await this.updateConfig(
+ {
+ nomad_default_page_path: this.config.nomad_default_page_path,
+ },
+ null
+ );
+ },
async onStrangerAttachmentBlockChange(value) {
this.config.block_attachments_from_strangers = value;
await this.updateConfig({ block_attachments_from_strangers: value }, "stranger_protection");
diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index 4052f2a6..8946ea62 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -207,14 +207,6 @@ export default {
titleKey: "docs.title",
descriptionKey: "docs.subtitle",
},
- {
- name: "licenses",
- route: { name: "licenses" },
- icon: "license",
- iconBg: "tool-card__icon bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
- titleKey: "licenses.title",
- descriptionKey: "licenses.short_description",
- },
{
name: "micron-editor",
route: { name: "micron-editor" },
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────